在過去幾篇文章中,我們認識了 Functor 這個 FP 工具,透過 .map
,我們學會了如何在一個「容器」或「上下文 (context)」內,對值進行操作,而完全不用擔心容器本身的結構。無論是可能為空的 Maybe
、帶有錯誤分支的 Either
,還是封裝著副作用的 IO
與 Task
,Functor 都讓我們能以一種優雅且可組合的方式來建立資料處理管道。
這一切看起來非常美好,我們的函數組合 (compose
或 pipe
) 如絲般順滑。只要我們的函數是單純地從 a
一般值轉換到 b
一般值(a -> b
),Functor 的世界就完美無瑕。
但現在有個問題:「如果我們想要 map
的那個函式,它本身的回傳值也是一個容器呢?例如 a -> M(b)
這樣?」
當我們將 M(b)
組合到下一個資料處理管道時,就像是為一顆洋蔥包上了另一層皮。我們得到的不是 Maybe(user)
,而是 Maybe(Maybe(user))
;不是 IO(data)
,而是 IO(IO(data))
。
圖 1 Functor 的 .map
處理回傳容器的函數時,會產生巢狀結構(資料來源: 自行繪製)
這就是「巢狀洋蔥」問題,而今天要介紹的 Monad 就是為了解決這問題,以便我們繼續流暢的組合,接著就來看看吧~
of
的意義在探討 Monad 如何解決巢狀問題之前,先回顧一個我們已經熟悉,但可能還未完全理解其重要性的方法:.of
。
一開始我們可能認為 Maybe.of(x)
只是 new Maybe(x)
的一種語法糖,或是一種避免使用 new
關鍵字的 FP 風格。但它的意義不止於此。
一個實作了 of
方法的 Functor,我們稱之為 Pointed Functor。
.of
的真正目的,是提供一個標準化的介面,將任何一個「一般值世界」的值,放入該 Functor 的「預設最小脈絡 (default minimal context)」中。它回答了這個問題:「如果要把一個一般值放進這個容器裡,最安全、最通用的方式是什麼?」
每個 Functor 只能有一種放入值的方式,以 Either 為例,Either 有 Left
和 Right
兩種狀態,但只有 Right
是可以被 .map
的。因此,Either 的「預設最小脈絡」就是 Right。這就是為什麼 Either.of(5)
的結果會是 Right(5)
,而不是 Left(5)
。Left.of
在概念上是沒有意義的,因為 Left
代表計算的中斷,而不是一個可以繼續操作的容器。
在不同的函式庫或文獻中,of
也被稱為 pure
、unit
或 return
。它們本質上都在描述相同的功能:將一個一般值「提升」到容器的脈絡中,換句話說,of
會將一個值從「一般值的世界」提升到「容器包裹值」的世界。
圖 2 of
會將一個值從「一般值的世界」提升到「容器包裹值」的世界(資料來源: 自行繪製)
理解了 Pointed Functor,就比較能理解 Monad 的定義,稍後會看到,Monad 的定義就是:「一個可以被壓平的 Pointed Functor」。
先從一個熟悉的 Maybe Functor 開始,看看它在巢狀組合中的問題。
假設我們要處理一個使用者物件,目標是取得該使用者地址中的街道內容。這過程有三個步驟,且每一步都可能失敗:
簡單來說這是一個 user.addresses[0].street
的巢狀取值,每一層取值都可能遇到值不存在的狀況,為了處理這種「可能不存在」的情況,我們可使用 Maybe,並定義兩個「安全」的函式,它們會將可能為 null
或 undefined
的結果包裝進 Maybe 容器中:
// --- 小工具 -----------------------------------------------------------
// compose :: ((b -> c), (a -> b)...) -> a -> c
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
// curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c
const curry = (fn) => {
const arity = fn.length;
const $curry = (...args) => {
if (args.length < arity) {
return $curry.bind(null, ...args);
}
return fn.call(null, ...args);
};
return $curry;
};
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((fn, f) => f.map(fn));
// --- Maybe -------------------------------------------------------
const Maybe = {
of: (value) =>
value === null || value === undefined ? new Nothing() : new Just(value)
};
class Just {
constructor(value) {
this.$value = value;
}
map(fn) {
return Maybe.of(fn(this.$value));
}
getOrElse(defaultValue) {
return this.$value;
}
toString() {
return `Just(${this.$value})`;
}
}
class Nothing {
map(fn) { return this; }
getOrElse(defaultValue) { return defaultValue; }
toString() { return 'Nothing()'; }
}
// --- 安全取值的函式 -------------------------------------------
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((key, obj) => Maybe.of(obj?.[key]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
現在我們試著用 .map
把這三步串連起來:
// --- 取第一個 address 的 street ------------------------------
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp('street'))), // [3] Maybe(Maybe(Address)) -> Maybe(Maybe(Maybe(Street)))
map(safeHead), // [2] Maybe([Address]) -> Maybe(Maybe(Address))
safeProp('addresses') // [1] User -> Maybe([Address])
);
然後傳入我們的 user
資料:
const userNoAddresses = { name: 'Amy' };
const userWithStreet = {
name: 'John',
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
};
const nothingResult = firstAddressStreet(userNoAddresses);
const nestedResult = firstAddressStreet(userWithStreet);
console.log(nothingResult); // Nothing {}
console.log(nestedResult); // Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))
可以看到當我們傳入 userWithStreet
時,得到的不是 Maybe(address)
,而是 Maybe(Maybe(Maybe(address)))
。這就是我們所說的「巢狀的洋蔥」或「盒子裡的盒子」。
完整程式可參考此連結。
它破壞了我們一直以來努力維護的組合性 (Composition)。
我們無法繼續用 .map
來串接下一個操作。如果我們寫 nestedResult.map(getStreetNumber)
,getStreetNumber
這個函式會被應用在 Maybe(Maybe(address))
上,而不是它期望的 address
物件上,這會導致非預期的結果或錯誤。我們的函數處理管道在此卡住了。
為了從 Maybe(Maybe(Maybe(address)))
中取出最終的街道資料,我們必須手動地、命令式地「拆箱」:先檢查外層的 Maybe 是 Just
還是 Nothing
,如果是 Just
,再取出裡面的 Maybe,再對它進行一次檢查... 這違背了我們使用 Maybe 來避免 if/else
巢狀地獄的初衷。
Functor 的 .map
是我們在 context 中(或說是容器中)進行組合的關鍵。它讓我們可以順暢地建立 pipe(f, g, h)
這樣的處理流程。然而,當流程中的某個函式(如 getAddress
)本身就會創造一個新的 context 或容器時,.map
只是忠實地將這個新context 包進來,導致了 M(M(b))
這種「阻塞物」。
我們需要一個能夠理解並處理這種「函數回傳容器」情況的工具,進而修復我們斷掉的組合鏈。
join
為了解決這個「巢狀容器」問題,Monad 引入了一個函數:join
。join
的功能非常單純:將任何兩層相同型別的容器壓平 (flatten) 成一層,以下是 join
的型別簽章。
join :: Monad m => m (m a) -> m a
圖 3 join
能將兩層相同型別的容器壓平為一層(資料來源: 自行繪製)
它的作用就像是從一個箱子裡,把內部的那個箱子拿出來,丟掉外層的箱子。讓我們看看 join
如何改善我們的程式碼:
const join = m => m.join();
const firstAddressStreet =
compose(
join, // Maybe(street)
map(safeProp('street')), // Maybe({...}) -> Maybe(Maybe(street))
join, // Maybe(head)
map(safeHead), // Maybe([...]) -> Maybe(Maybe(head))
safeProp('addresses') // obj -> Maybe(addresses)
);
const result = firstAddressStreet(userWithStreet); // Maybe({name: 'Mulburry', number: 8402})
在每個產生新 Maybe
的 .map
操作後加上 .join()
,我們成功地將結構的深度控制在了一層。程式碼不再是可怕的巢狀 map
,而是線性的鏈式呼叫。
而 Maybe 的 join
方法可以這樣定義,以下將現有的 Maybe 加上 join
方法:
// 這裡用 instanceof 判斷是否為 Maybe,但實務上可改用 _tag 來辨別型別
const isMaybe = (x) => x instanceof Just || x instanceof Nothing;
const Maybe = {
of: (value) =>
value === null || value === undefined ? new Nothing() : new Just(value)
};
class Just {
constructor(value) {
this.$value = value;
}
map(fn) {
return Maybe.of(fn(this.$value));
}
getOrElse(defaultValue) {
return this.$value;
}
toString() {
return `Just(${this.$value})`;
}
// 新增 join 方法
join() {
return isMaybe(this.$value) ? this.$value : this;
}
}
class Nothing {
map(fn) { return this; }
getOrElse(defaultValue) { return defaultValue; }
toString() { return 'Nothing()'; }
// 新增 join 方法
join() { return this; }
}
完整程式碼可參考此連結。
這種可以被「壓平」的能力,正是 Monad 之所以為 Monad 的關鍵特徵之一。現在,前言提到的定義就說得通了:
一個 Monad,就是一個可以被壓平的 Pointed Functor。(A Monad is a pointed functor that can flatten.)
另一個更常見的定義敘述是:
一個型別若同時提供
of
與chain
,並且滿足 Monad 的三條定律(結合律、左右單位律),那它就是 Monad。
chain
雖然 join
解決了巢狀問題,但可能有人會注意到,map(f).join()
這種模式在程式碼中不斷重複出現,顯得有些累贅。既然這個模式如此常用,我們何不把它們打包成一個新的方法呢?
這就是 chain
誕生的原因。chain
= map
+ join
。
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// 或者
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));
chain
的其他稱呼例如:
>>=
(稱為 bind
)flatMap
chain
chain
將 map
和 join
這兩個步驟合併為一個操作。
圖 4 chain(f)
等價於 map(f)
加上 join
(資料來源: 自行繪製)
現在我們用 chain
來重寫 firstAddressStreet
:
const firstAddressStreet = compose(
chain(safeProp('street')), // head -> Maybe(street)
chain(safeHead), // addresses -> Maybe(head)
safeProp('addresses') // obj -> Maybe(addresses)
);
完整程式請見此連結。
chain
隱藏了 map
和 join
的細節,讓我們可以專注於組合我們的業務邏輯,而不用擔心容器的巢狀問題。
m.chain(f)
這種呼叫形式被稱為 infix (中綴) 或方法形式,因為 chain
寫在物件和函式之間。而在許多函式庫(如 Ramda)中,可能也會看到 prefix (前綴) 或函式形式,也就是將 chain
寫在最前面,作為一般函數來呼叫:
// Infix (方法形式)
Maybe.of(3).chain(x => Maybe.of(x + 1));
// Prefix (函式形式),資料(functor)置後
// chain(f, m)
chain(x => Maybe.of(x + 1), Maybe.of(3));
兩者在概念上是等價的,只是呼叫風格不同。
以下幾點回顧今天文章重點。
map
遇到的問題:巢狀容器當我們用 Functor 的 .map
處理一個會回傳容器(例如 a -> M(b)
)的函數時,會產生「盒子裡的盒子」的巢狀結構(M(M(b))
),這會破壞函數組合的流暢性。
我們可用兩種工具來解決這個問題:
.join()
:一個簡單的「壓平」操作,能將兩層相同的容器扁平化為一層.chain()
:一個更方便的工具,它將 map
和 join
這兩個步驟合而為一。.chain(f)
相當於 .map(f).join()
Monad 可以理解為「一個可以被壓平的 Pointed Functor」,也等價於「實作了 of
與 chain
並遵守某些定律的型別」。Monad 讓我們可以將多個帶有 context 的計算串接成一個扁平、線性的處理流程,避免了手動拆箱和繁瑣的巢狀程式碼。
我們已經看到了 chain
如何解決巢狀問題,但要一個型別真正成為 Monad,它還需要滿足一些定律。這些定律確保了 chain
的行為是可預測的。在下一篇文章中,我們會再更瞭解 Monad 到底是什麼,以及它要遵循哪些定律。